S05-01 多线程与并发-多线程
[TOC]
概述
进程与线程
进程(Process):操作系统分配资源(如内存)的最小单位。一个独立运行的程序就是一个进程(比如你打开的一个浏览器)。线程(Thread):CPU 调度的最小单位。它是进程中的一个执行流程。一个进程可以包含多个线程,这些线程共享该进程的内存资源(如堆内存和方法区),但每个线程有自己独立的程序计数器(PC)和虚拟机栈。
一句话总结: 进程就像是一辆火车,而线程就是火车上的一节节车厢。多线程就是让这列火车有多个车厢同时运作。
图示:JVM 中的线程
不同的进程之间是不共享内存的。
每个线程独立的拥有自己的:虚拟机栈、本地方法栈、程序计数器。一个进程中的多个线程,共享进程的:方法区、堆。
进程之间的数据交换和通信的成本很高。

并发与并行
这两个概念经常被混淆,但它们在计算机科学中有着明确的区别:
并发 (Concurrency):指的是系统拥有处理多个任务的能力,但不一定是同时执行。在单核 CPU 中,操作系统通过快速切换时间片,让多个线程交替执行,使得宏观上看起来像是在“同时”运行。并行 (Parallelism):指的是系统拥有同时执行多个任务的能力。这通常需要多核 CPU 的支持,真正的在同一物理时刻,不同的核心在执行不同的线程。
比喻:
- 并发是一个咖啡机,两个人排队交替接咖啡;
- 并行是两台咖啡机,两个人同时各自接咖啡。

线程调度
在 Java 多线程编程中,当有多个线程处于可运行(RUNNABLE)状态时,谁先执行、谁后执行、每个线程执行多久,这就涉及到了线程调度(Thread Scheduling)。
理解线程调度,有助于我们明白为什么多线程程序的执行结果往往是“不可预测”的,以及我们能在多大程度上干预这种调度。
在计算机科学中,主要有两种线程调度模型:
协同式调度
方式一:协同式调度 (Cooperative Scheduling):
- 机制: 线程的执行时间由线程本身控制。一个线程执行完自己的工作后,主动通知系统切换到另外一个线程。
- 优点: 实现简单,没有线程同步的问题(因为什么时候切换是确定的)。
- 缺点: 极其危险。如果一个线程编写有问题,一直不让出 CPU(比如死循环),会导致整个系统崩溃。
抢占式调度
方式二:抢占式调度 (Preemptive Scheduling) —— Java 的选择:
- 机制: 线程的执行时间由**操作系统(调度器)**来分配。操作系统会给每个线程分配一个“时间片”(Time Slice,通常是几十毫秒)。时间片用完,或者发生阻塞时,操作系统会强制剥夺该线程的 CPU 执行权,并把 CPU 交给其他处于就绪状态的线程。
- 优点: 一个线程的阻塞或死循环不会导致整个系统崩溃,多任务并发性好。
- 缺点: 线程切换频繁会有上下文切换(Context Switch)开销;会导致线程安全问题,需要开发者手动进行同步(加锁等)。
核心结论: Java 的线程调度是抢占式的。这意味着 Java 程序无法绝对控制哪个线程在什么时刻执行,只能对调度器提出“建议”。

线程优先级
线程优先级 (Thread Priority):
既然调度由 OS 决定,Java 提供了 setPriority(int newPriority) 方法,试图给调度器一些“建议”。
Java 线程优先级范围是 1 到 10:
Thread.MIN_PRIORITY(1)Thread.NORM_PRIORITY(5) —— 默认优先级Thread.MAX_PRIORITY(10)
优先级规则与致命陷阱:
高优先级不等于先执行: 优先级高的线程只是获取 CPU 时间片的概率更大,绝不意味着它一定会在低优先级线程之前执行完毕。
OS 映射差异: Java 有 10 个优先级,但底层操作系统可能没有这么多。比如 Windows 有 7 个,Linux 的某些调度策略下优先级可能完全被忽略。多个 Java 优先级可能被映射到同一个 OS 优先级上。
饥饿 (Starvation): 如果你把某个线程优先级设得极低,在 CPU 繁忙时,它可能永远抢不到时间片,导致“饿死”。
实战建议: 在实际的业务开发中,绝对不要依赖线程优先级来控制程序的业务逻辑和执行顺序! 它极不可靠。通常我们都保持默认的优先级(5)即可。
单核和多核
单核CPU:在一个时间单元内,只能执行一个线程的任务。
例如,可以把CPU看成是医院的医生诊室,在一定时间内执行一行代码(给一个病人诊断治疗)。所以单核CPU就是,代码经过前面一系列的前导操作(类似于医院挂号),然后到cpu处执行时发现,就只有一个CPU,大家排队执行。(类似于10个挂号窗口挂号,结果跑到医生那只有一个医生,只能排队等)。
这时候想要提升系统性能,只有两个办法,要么提升CPU性能(让医生看病快点),要么多加几个CPU(多整几个医生)。后者即为提供多核CPU。如果是多核的话,才能更好的发挥多线程的效率。(现在的服务器都是多核的)。
问题:多核的效率是单核的倍数吗?
譬如4核A53的cpu,性能是单核A53的4倍吗?理论上是,但是实际不可能,至少有两方面的损耗。
- 一个是多个核心的其他共用资源限制。譬如,4核CPU对应的内存、cache、寄存器并没有同步扩充4倍。这就好像医院一样,1个医生换4个医生,但是做B超检查的还是一台机器,性能瓶颈就从医生转到B超检查了。
- 另一个是多核CPU之间的协调管理损耗。譬如多个核心同时运行两个相关的任务,需要考虑任务同步,这也需要消耗额外性能。好比公司工作一样,一个人的时候至少不用开会浪费时间,自己跟自己商量就行了。两个人就要开会同步工作,协调分配,所以工作效率绝对不可能达到2倍。
线程创建方式
严谨地说,Java 中创建线程本质上只有一种方式:构造一个 java.lang.Thread 类的实例,并调用其 start() 方法。所谓的“多种方式”,其实是指封装线程执行任务(Task)的方式不同。
以下是 Java 中常见的四种创建并启动线程的方式详解:
方式1:继承 Thread 类
继承 :Thread 类
这是最直观、最古老的方式。你只需要创建一个类继承 Thread,并重写它的 run() 方法。
代码示例:
// 1. 自定义类继承 Thread
class MyThread extends Thread {
// 2. 重写 Thread 类的 run() 方法
@Override
public void run() {
// 线程要执行的业务逻辑
System.out.println(Thread.currentThread().getName() + " 正在执行...");
}
}
public class ThreadDemo {
public static void main(String[] args) {
// 3. 创建线程对象
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
// 4. 调用 start() 方法启动线程
t1.start();
t2.start();
}
}- 优点: 编写简单,如果在
run()方法内部需要获取当前线程,直接使用this即可,无须调用Thread.currentThread()。 - 缺点: 极其不推荐在现代开发中使用。因为 Java 是单继承的,如果你的类已经继承了
Thread,就无法再继承其他业务类,大大限制了代码的扩展性。此外,任务(run方法)和线程(Thread对象)强耦合在一起。
练习:
创建一个分线程1,用于遍历 100 以内的偶数

创建两个分线程,一个线程用于遍历 100 以内的偶数,另一个线程用于遍历 100 以内的奇数
方式一:使用标准方法

方式二:使用匿名类的方式

方式2:实现 Runnable 接口
实现 :Runnable 接口
为了解决单继承的限制,Java 提供了 Runnable 接口。你将需要执行的任务写在 Runnable 实现类的 run() 方法中,然后将这个实现类作为一个“目标任务”丢给 Thread 对象去执行。
标准写法:
// 1. 实现 Runnable 接口
class MyRunnable implements Runnable {
// 2. 实现 Runnable 接口的 run() 方法
@Override
public void run() {
// 线程要执行的业务逻辑
System.out.println(Thread.currentThread().getName() + " 正在执行 Runnable 任务...");
}
}
public class RunnableDemo {
public static void main(String[] args) {
// 3. 创建当前任务对象
MyRunnable task = new MyRunnable();
// 4. 将任务对象传入 Thread 构造器,并创建 Thread 类的实例对象
Thread t1 = new Thread(task, "线程-A");
Thread t2 = new Thread(task, "线程-B");
// 5. 调用 start() 方法启动线程
t1.start();
t2.start();
}
}匿名实现类写法:
public class RunnableDemo {
public static void main(String[] args) {象
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
// 线程要执行的业务逻辑
System.out.println(Thread.currentThread().getName() + " 正在执行 Runnable 任务...");
}
}, "线程-A").start();
}
}现代写法(使用 Lambda 表达式,Java 8+):
Thread t3 = new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " 使用 Lambda 执行任务...");
}, "线程-C");
t3.start();优缺点:
- 优点:
- 打破了单继承的局限性,类还可以继承其他类。
- 解耦:将线程和任务彻底分离。
- 非常适合多个相同线程去处理同一个资源的情况(例如上述代码中
t1和t2共享同一个task对象)。
- 缺点:
run()方法没有返回值,也无法抛出受检异常(Checked Exception),只能在方法内部try-catch。
注意:Thread类实际上也是实现了Runnable接口的类(代理模式):
javapublic class Thread extends Object implements Runnable
练习:
程序阅读

方式3:实现 Callable 接口与 FutureTask
API:FutureTask 类
FutureTask API:
构造方法:
- FutureTask():
(Callable<V> callable),构造方法,将一个带有返回值的Callable任务包装成FutureTask。 - FutureTask():
(Runnable runnable, V result),构造方法,包装一个Runnable任务,并提前指定好一个返回值result。
实例方法:
Vget():(),获取计算结果(死等派)。
极其重要:如果任务还没执行完,调用这个方法的当前线程会被完全阻塞挂起,直到任务执行完毕返回结果,或者抛出异常。Vget():(long timeout, TimeUnit unit),获取计算结果(限时派,极度推荐)。
如果到了指定时间任务还没出结果,会抛出TimeoutException。在企业开发中,强烈禁止使用无参的get()以防止主线程被永远卡死。
实现 Callable 接口与 FutureTask
如果你希望线程执行完毕后能返回一个结果,或者能抛出异常供外部捕获,那么就需要使用 Callable 接口(Java 5 引入)。
因为 Thread 类的构造器只接受 Runnable,不接受 Callable,所以我们需要一个桥梁——FutureTask。FutureTask 实现了 Runnable 接口,同时它的构造器可以接收 Callable。
代码示例:
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
// 1. 实现 Callable 接口,泛型 <Integer> 代表返回值的类型
class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
System.out.println(Thread.currentThread().getName() + " 正在计算...");
Thread.sleep(2000); // 模拟耗时操作
return 100 + 200;
}
}
public class CallableDemo {
public static void main(String[] args) throws Exception {
// 2. 创建 Callable 任务
MyCallable task = new MyCallable();
// 3. 使用 FutureTask 包装 Callable
FutureTask<Integer> futureTask = new FutureTask<>(task);
// 4. 将 FutureTask 交给 Thread 执行
Thread t1 = new Thread(futureTask, "计算线程");
t1.start();
System.out.println("主线程可以继续做其他事情...");
// 5. 获取结果。注意:get() 方法会阻塞当前主线程,直到 call() 执行完毕并返回结果!
Integer result = futureTask.get();
System.out.println("计算结果是: " + result);
}
}- 优点: 功能最强大,有返回值,能抛出异常。通过
FutureTask还可以取消任务、判断任务是否完成。 - 缺点: 代码相对繁琐。调用
get()方法时如果没有设置超时时间,可能会导致阻塞。
方式4:使用线程池(推荐)@
使用线程池(Executor 框架):
在实际的企业级项目开发中,我们几乎从来不会手动去 new Thread()。因为频繁创建和销毁线程会消耗极大的系统资源,并且难以统一管理,容易导致内存溢出(OOM)。
业界标准做法是使用线程池。你只需要把 Runnable 或 Callable 任务提交给线程池,线程池会自动分配工作线程来执行它们。
代码示例:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class ThreadPoolDemo {
public static void main(String[] args) throws Exception {
// 1. 创建一个固定大小为 3 的线程池
ExecutorService threadPool = Executors.newFixedThreadPool(3);
// 2. 提交 Runnable 任务 (使用 execute)
threadPool.execute(() -> {
System.out.println(Thread.currentThread().getName() + " 执行无返回值的任务");
});
// 3. 提交 Callable 任务 (使用 submit)
Future<String> future = threadPool.submit(() -> {
System.out.println(Thread.currentThread().getName() + " 执行有返回值的任务");
return "Task Success";
});
System.out.println("Callable 返回值: " + future.get());
// 4. 关闭线程池 (不再接受新任务,等待已有任务执行完毕)
threadPool.shutdown();
}
}优点:
- 资源复用:避免频繁创建销毁线程。
- 响应速度快:任务来了直接拿池子里的空闲线程执行。
- 便于管理:可以控制最大并发数,提供定时执行、周期执行等功能。
Thread
java.lang.Thread 是 Java 中进行多线程编程的最核心、最基础的类。在 Java 的世界里,任何代码的执行最终都是由 Thread 类的实例来驱动的。
理解 Thread 类的内部结构、构造方式以及它提供的丰富方法,是掌控 Java 并发编程的必经之路。
以下是对 Thread 类的全面解剖:
类的声明与本质
类的声明与本质:
打开 JDK 源码,你可以看到 Thread 类的声明如下:
public class Thread implements Runnable {
// ...
}这说明了一个非常重要的设计:Thread 类本身也实现了 Runnable 接口。
这意味着,Thread 不仅是线程的“驱动器”(负责与底层操作系统交互并分配 CPU 资源),它自己也可以被看作是一个包含了 run() 方法的“任务”。当我们直接继承 Thread 类并重写 run() 方法时,其实就是把“驱动器”和“任务”绑定在了一起。
API:Thread 类
属性
每个 Thread 对象在底层都有几个极其关键的属性来标识它的状态和特征:
属性:
Stringname:线程名称。每个线程都有一个名字。如果没有手动指定,Java 会自动生成类似Thread-0,Thread-1的名字。
在实际开发中,强烈建议给线程起一个有业务意义的名字,这在排查日志和死锁时是救命的。intpriority:线程优先级。范围从 1 (MIN_PRIORITY) 到 10 (MAX_PRIORITY),默认值是 5 (NORM_PRIORITY)。
注意:优先级高的线程理论上获取 CPU 时间片的概率更大,但这完全取决于操作系统的具体实现。在 Java 开发中,绝对不能依赖线程优先级来控制业务逻辑的先后顺序。booleandaemon:是否为守护线程。分为用户线程(User Thread)和守护线程(Daemon Thread)。Runnabletarget:目标任务。这就是你在调用new Thread(Runnable task)时传进去的那个任务对象。
构造方法
Thread 类提供了多个重载的构造方法,最常用的有以下几个:
构造方法:
Thread():
(),创建一个新的线程对象,名称自动生成。Thread():
(String name),创建具有指定名称的线程对象。Thread():
(Runnable target),将一个Runnable任务对象传递给线程,由线程负责执行该任务。Thread():
(Runnable target, String name),最推荐的用法:同时指定要执行的任务和线程名称。
静态方法
为了便于记忆,我们可以将 Thread 类的方法分为三类:静态工具方法、线程控制方法和属性获取/设置方法。
静态方法:作用于当前正在执行的线程
static Thread
currentThread():(),极度常用。返回对当前正在执行的线程对象的引用。常用于获取当前线程的名称:Thread.currentThread().getName()。static voidsleep():(long millis),让当前线程休眠(暂停执行)指定的毫秒数。进入TIMED_WAITING状态。注意:休眠期间不会释放已经持有的任何对象锁。static voidyield():(),线程让步。当前线程主动提示调度器自己愿意让出 CPU 的使用权,状态由运行中变为就绪(Runnable)。但操作系统可以选择忽略这个提示。
实例方法:线程控制
实例方法:线程控制:
voidstart():启动线程。通知 Java 虚拟机为其分配系统资源,并在就绪后调用该线程的run()方法。一个线程的start()方法只能被调用一次,否则抛IllegalThreadStateException。voidrun():线程要执行的具体的业务代码实体。如果直接调用它,它会被当作当前线程下的一个普通方法执行,不会启动新线程。voidjoin():等待该线程终止。比如在主线程中调用t1.join(),那么主线程会一直阻塞,直到t1线程执行完毕。常用于等待其他线程的计算结果。voidinterrupt():中断线程。它并不会粗暴地立即停止线程,而是给目标线程打上一个“中断标记”。目标线程需要配合检查这个标记来决定是否安全地退出。booleanisInterrupted():检查中断标记。测试该线程是否已经被中断。booleanisAlive():测试该线程是否处于活动状态(已经start()且尚未终止)。
实例方法:属性控制
实例方法:属性控制:
final StringgetName()final voidsetName():(String name),获取/设置线程名。final intgetPriority()final voidsetPriority():(int newPriority),获取/设置优先级。final booleanisDaemon()final voidsetDaemon():(boolean on),将该线程标记为守护线程或用户线程。必须在start()方法之前调用,否则抛出异常。
注意事项
start() vs run()
误区一::start() 和 run() 的天壤之别
- 调用
start():真正向操作系统申请创建了一个新的本地线程,新线程启动后会自动去执行run()里的代码。这叫多线程。 - 调用
run():仅仅是在当前线程中执行了一个名为run的普通对象方法而已,根本没有创建新线程。这叫同步执行。
守护线程
误区二:什么是“守护线程 (Daemon)”:
- 用户线程(默认): 只要还有任何一个非守护线程在运行,JVM 就不会退出。你的
main主线程就是一个典型的用户线程。 - 守护线程(后台线程): 为其他线程提供服务的线程(比如 JVM 的垃圾回收线程 GC)。当所有的用户线程都执行完毕退出时,JVM 会毫不犹豫地直接关闭,此时所有的守护线程也会随之被立即强行终止。
场景:如果你在后台运行一个定时清理临时文件的线程,应该把它设置为守护线程,这样当你的主程序结束时,它不会阻止程序的退出。
练习:sleep()
题目:如下的代码中 sleep() 执行后,哪个线程进入了阻塞状态?

回答:主线程进入了阻塞状态。
线程生命周期
要真正写出健壮的并发程序,或者在生产环境中排查 CPU 飙高、程序卡死(死锁)等问题,深刻理解 Java 线程的生命周期是必不可少的基本功。
API:Thread.State 枚举
在 Java 中,线程的生命周期并不是由操作系统直接决定的,而是由 JVM 明确规定在 java.lang.Thread.State 枚举类中的 6 种状态。
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}线程的生命周期状态:
NEW:新建。线程对象已经被创建出来,但是还没有调用
start()方法。- 状态解析: 此时它只是堆内存中的一个普通的 Java 对象,操作系统内核中还没有对应的底层线程。
- 代码场景:
Thread t = new Thread();
RUNNABLE:可运行。线程已经调用了
start()方法,随时准备好执行,或者正在执行中。- 状态解析: 注意,这是一个极易误解的点! 在操作系统的层面,线程分为“就绪(Ready,等待 CPU 分配时间片)”和“运行(Running,正在 CPU 上执行)”。但在 Java 的世界里,JVM 将这两种状态合并统称为
RUNNABLE。因此,处于RUNNABLE状态的线程可能正在疯狂运行,也可能在排队等 CPU。 - 状态流转:
NEW-> 调用t.start()->RUNNABLE。
- 状态解析: 注意,这是一个极易误解的点! 在操作系统的层面,线程分为“就绪(Ready,等待 CPU 分配时间片)”和“运行(Running,正在 CPU 上执行)”。但在 Java 的世界里,JVM 将这两种状态合并统称为
BLOCKED:阻塞。线程试图获取一个内部的对象锁(Monitor Lock),但该锁正被其他线程持有,因此当前线程被阻塞。
- 状态解析: 在 Java 中,只有在等待进入
synchronized代码块或方法时,线程才会进入BLOCKED状态。等待java.util.concurrent.locks.Lock(如ReentrantLock)时,线程进入的是WAITING状态,而不是BLOCKED。 - 状态流转:
RUNNABLE-> 竞争synchronized锁失败 ->BLOCKED。BLOCKED-> 成功获取到锁 ->RUNNABLE。
- 状态解析: 在 Java 中,只有在等待进入
WAITING:等待。线程进入一种“无限期等待”的状态。它放弃了 CPU 的使用权,并且不会自动醒来,必须等待另一个线程执行特定的唤醒操作。
- 状态解析: 处于这种状态的线程通常是在等待某个条件成立。
- 触发条件(从 RUNNABLE 变为 WAITING):
- 调用了没有设置超时时间的
Object.wait()。 - 调用了没有设置超时时间的
Thread.join()(本质上也是调用的wait())。 - 调用了
LockSupport.park()(JUC 锁的底层基石)。
- 调用了没有设置超时时间的
- 唤醒条件(从 WAITING 变为 RUNNABLE):
- 其他线程调用了该对象的
Object.notify()或Object.notifyAll()。 LockSupport.unpark(Thread)被调用。
- 其他线程调用了该对象的
TIMED_WAITING:计时等待。类似于
WAITING,但它是“限时等待”。线程等待一段时间,如果时间到了还没有被唤醒,它会自动醒来并尝试继续执行。- 触发条件(从 RUNNABLE 变为 TIMED_WAITING):
Thread.sleep(long millis)(最常见,抱着锁睡觉)。- 带有超时参数的
Object.wait(long timeout)。 - 带有超时参数的
Thread.join(long millis)。 LockSupport.parkNanos()或LockSupport.parkUntil()。
- 唤醒条件(从 TIMED_WAITING 变为 RUNNABLE):
- 等待时间到达。
- 提前被
notify()或notifyAll()唤醒。
- 触发条件(从 RUNNABLE 变为 TIMED_WAITING):
TERMINATED:终止。线程的生命周期走到了尽头,已经停止运行。
- 状态解析: 一旦线程进入
TERMINATED状态,就绝对不可能再复活。如果你尝试对一个已终止的线程再次调用start()方法,会抛出IllegalThreadStateException异常。 - 触发条件:
run()方法中的代码正常执行完毕。- 线程在执行过程中抛出了一个未捕获的异常(Exception 或 Error),导致线程意外死亡。
- 状态解析: 一旦线程进入
生命周期图解
线程的生命周期:
JDK5及之后:

JDK5之前:

易错点总结
核心面试/实战易错点总结:
为了帮你更好地消化,这里对比几个容易混淆的场景:
| 场景区别 | 核心说明 |
|---|---|
BLOCKED vs WAITING | BLOCKED 是因为“别人抢了我的锁,我只能干等”;WAITING 是因为“我自己主动退居幕后,等待别人给我发信号”。 |
sleep() vs wait() | sleep() 进入 TIMED_WAITING,且不释放锁;wait() 进入 WAITING,且必须释放锁,让出临界区资源。 |
JUC 锁 vs synchronized | 使用 ReentrantLock.lock() 等不到锁时,线程进入的是 WAITING 状态(底层是 LockSupport.park()),而不是 BLOCKED。 |
理解了线程的生命周期后,我们在实际开发中往往需要对这些状态进行干预,比如在一个线程处于 WAITING 或 TIMED_WAITING 时安全地打断它。
练习:新年倒计时
题目:模拟新年倒计时,每隔1秒输出一个数字,依次输出:10,9,8...1,最后输出:新年快乐!
代码实现:
